iT邦幫忙

2021 iThome 鐵人賽

DAY 22
1

本節是以 Golang 上游 6a79f358069195e1cddb821e81fab956d9a0c7d1 為基準做的實驗

予焦啦!昨日以前的第三章解決了計時器中斷,今天我們將邁入以排程為主要目標的新章。過關斬將至此,已經不得不面對了。

有些讀者可能會誤以為昨日最後展示的結果當中,不斷刷出的 i = n 訊息代表被計時器中斷的次數,事實上這是筆者描述不清。那只是為了讓 ethanol 的執行可以先不要撞到錯誤,而用無窮迴圈卡住;不斷刷出只是在展示,就算一直經歷計時器中斷,也能夠因為正確的上下文儲存、回復機制,持續回到無窮迴圈裡面,並繼承正確的數字 n 執行下去。

本節重點概念

  • Golang
    • M 物件相關的重要方法(method)
  • 其他
    • 作業系統提供的創建執行緒頁面

整理現狀

最後一次之前,我們不得不卡在 newosproc 函數裡面、踩到 panic 之前的無限迴圈之中:

func newosproc(mp *m) {
        for {
                ...
        }
        panic("newosproc: not implemented")
}

若非我們使用無窮迴圈將之暫停,這裡的 panic 就將直接導致程式流程的終止了。至於此時的狀態分析,強烈建議參考計時器中斷之前的斷章之一大致上來說,之所以會走到這裡來,是因為 Golang 執行期的設計,需要一個監控者執行緒(monitor thread)。這裡觀測到的現象,只是最後的結果。

趁現在的機會,就介紹一些 Golang 的執行緒物件(m 結構)相關的方法(method),以下拆分成數小節。

生成 Golang 執行緒(m

欲了解 Golang 的執行緒生成過程,在需要系統呼叫之前已經完成哪些事情,請務必參考拙作。本小節只做部分補充。

回顧一下呼叫堆疊:

runtime.newosproc(...)                                                                          
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/os_opensbi.go:154
runtime.newm1(0xffffffcf0402a000)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2244 +0x104
runtime.newm(0xffffffc000063cd8, 0x0, 0xffffffffffffffff)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2223 +0x108
runtime.main.func1()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:175 +0x3c

allocm

主要的入口為 newm 函數。它最一開始的函數呼叫 allocm,於註解中說明自己的目的為:配置一個新的 m 物件,而這個物件尚未與作業系統執行緒扯上關係。

在配置 m 物件所需的記憶體空間(使用 new 這個內建的 Golang 函數),並設置一些 m 結構體內的成員之前,還偷偷安插了一段邏輯,用以處理已經標記為閒置的執行緒。這類執行緒平常被記錄在 sched.freem 這一段列表之中(sched 是一個紀錄排程器(scheduler)相關狀態的大型結構)。註解中描述:這總得在每個時間點的某個地方執行,不如就在現在(要配置新的 m 物件時)做,或許還可以騰出空間給新的 m 使用。這個功能雖然並非主要目的、也就是配置相關的功能,但也是值得一提。

此外還有配置幾個 m 結構內的成員,

  • mstartfn,也就是這個 mmstart 系列呼叫之後,應該執行的函數。
  • g0,主要的共常式,由 malg 函數配置。除了 new(g) 以配置結構體之外,也一併進行所需的堆疊的配置。
  • id,用來標記這個執行緒的 ID。
  • gsignal:通常在作業系統各自定義的檔案中實作在 mpreinit 裡面。

還有一個步驟,

// Add to allm so garbage collector doesn't free g->m
// when it is just in a register or thread-local storage.
mp.alllink = allm
...
atomicstorep(unsafe.Pointer(&allm), unsafe.Pointer(mp))

allm 是 Golang 的所有執行緒列表,將之賦值給予 mp.alllink,就是讓新的這個執行緒(mp 是它的指標)以自己的 alllink 串起當前的列表開頭;再將列表開頭指向這個執行緒。之所以後者不能單純賦值就好,與一些 Cgo(C 與 Golang 之間的介面)有關,目前不在 Hoddarla 探討的範圍。

newm1

runtime 組件中我們看到很多這種命名規則了:newm 前後有一些處理,核心是 newm1 函數。

在這函數之中,主角就是 newosproc 函數的呼叫,其前後有 execLock 的兩個鎖(lock)在保護著。execLock 是一個全域變數,型別為 rwmutex,是從 sync 組件裡面複製出來修改為 runtime 組件所用的。這個同步用的資料結構可以允許多個執行緒競爭,能夠容忍多個讀取執行緒(rlock 上鎖,runlock 解鎖,也就是這裡給予 newosproc 的保護),或是單一一個寫入執行緒(lock 上鎖,unlock 解鎖)。

execLock 用來避免執行(exec)與複製(clone)的同時進行造成的怪問題。除了 runtime 這裡的這些使用,還有 syscall 組件裡面,相當於 Unix 作業系統的 execve 呼叫的 Exec 函數裡面也有使用到。

至此,是執行緒抽象物件由作業系統真正生成之前,Golang 的處置

執行緒啟動後的 mstart

本小節補述斷章 中的一些內容。

如前所述,在 newm 啟動生成執行緒流程之後,交付這項任務給作業系統之前,Golang 執行期預先指定的執行緒入口函數,即是 mstart

理想上,作業系統執行緒生成之後,應該會想辦法來到 mstart 之中,並附帶有 Golang 的諸般條件,如共常式的設置等等。

但這不是我們第一次接觸到 mstart。更早之前,在 rt0_go 的組合語言碼之中,這個 mstart 就已經用來生成 m0 了。先從概念上理解這件事情:m0 是 Golang 執行期最初期就已經存在的特殊執行緒,它所需的記憶體已經規劃在初值為 0 的 BSS 區域之中,所以,這樣的 m0 物件,當然沒有必要由 newm 這樣的入口函數來動態配置初始化。

src/runtime/asm_riscv64.s 當中,關於 mstart 函數的部分是 rt0_go 的尾聲與它的本體:

...
        // start this M
        CALL    runtime·mstart(SB)

        WORD $0 // crash if reached
        RET

TEXT runtime·mstart(SB),NOSPLIT|TOPFRAME,$0
        CALL    runtime·mstart0(SB)

在其它的組合語言檔案也能看到,這個 mstart 只是一個轉運站,之後會導向位於 src/runtime/proc.gomstart0,這就是通用的 Golang 實作了。

mstart0mstart1

主要是些堆疊的初始設置。若是有指定 mstartfn,則會呼叫進去,但如果沒有的話(如 m0 的情況),就會執行到我們先前也有簡單一瞥的 schedule 函數,將會試圖取得其他的共常式來執行。沒有其他意外的話,進入 schedule 之後就不會再回歸了。

這個部分有 g.sched 的設定,與之前我們在斷章當中追蹤的部分類似,也是在調整堆疊的呈現。

newmmstart 之間

在這兩者之間的,當然就是我們已經煩惱一陣子了的 newosproc,以及它如何呼叫作業系統服務,以生成新的執行緒。

簡單瀏覽之後可以發現,就算在類 Unix 家族裡面,也有三種以上不同的做法:

我們接下來稍微深入兩組系統組合,看看其中的奧祕如何。

linux/riscv64 系統組合

這個系統組合走得稍微迂迴一點,在 src/runtime/sys_linux_riscv64.s 之中,提供的組合語言函數和實際上的 clone 呼叫不太匹配。clone 系統呼叫所要求的參數之外,傳入的 mpgp 比較像是只對 Golang 有意義的內容。這些特殊參數在系統呼叫之前,被塞到 A1 的堆疊之中;事實上,這被預期是新執行緒的堆疊

// func clone(flags int32, stk, mp, gp, fn unsafe.Pointer) int32
TEXT runtime·clone(SB),NOSPLIT|NOFRAME,$0
        MOVW    flags+0(FP), A0
        MOV     stk+8(FP), A1

        // Copy mp, gp, fn off parent stack for use by child.
        MOV     mp+16(FP), T0
        MOV     gp+24(FP), T1
        MOV     fn+32(FP), T2

        MOV     T0, -8(A1)
        MOV     T1, -16(A1)
        MOV     T2, -24(A1)
        MOV     $1234, T0
        MOV     T0, -32(A1)

        MOV     $SYS_clone, A7
        ECALL

而且還多塞了一個 1234 數值到堆疊中,之所以需要這麼做,是要做一個心智檢查(sanity check,看到這個譯名,基本上我可以斷言國家教育研究院已經沒有認真在做翻譯了,向各位讀者說聲抱歉),確認整個複製流程沒有出錯。

在系統呼叫成功之後,這個流程回傳之後分岔為兩個執行緒,所以需要依其回傳值檢查:

        // In parent, return.
        BEQ     ZERO, A0, child
        MOVW    ZERO, ret+40(FP)
        RET

child:
        // In child, on new stack.
        MOV     -32(X2), T0
        MOV     $1234, A0
        BEQ     A0, T0, good
        WORD    $0      // crash

針對子執行緒的部分,只要能夠從堆疊上取回 1234 數值沒問題,就當作是複製成功了。執行到 good 標籤去:

good:
        // Initialize m->procid to Linux tid
        MOV     $SYS_gettid, A7
        ECALL

        MOV     -24(X2), T2     // fn
        MOV     -16(X2), T1     // g
        MOV     -8(X2), T0      // m

        BEQ     ZERO, T0, nog
        BEQ     ZERO, T1, nog

        MOV     A0, m_procid(T0)

        // In child, set up new stack
        MOV     T0, g_m(T1)
        MOV     T1, g

nog:
        // Call fn
        JALR    RA, T2
        // It shouldn't return.  If it does, exit this thread.
        MOV     $111, A0
        MOV     $SYS_exit, A7
        ECALL
        JMP     -3(PC)  // keep exiting

依序從堆疊取回 Golang 需要的入口函數(fn)、共常式(g)與執行緒物件(m)之後,因為需要設置 gm 的屬性,所以針對兩者傳入空值(nil)的狀況,就直接跳到呼叫入口函數的 nog 標籤去。

freebsd/arm64 系統組合

作為參考,我們也觀察一下這個組合。

值得一提的是,thr_new 這個系統呼叫的手冊上面說,正常的應用程式應該還是要使用 pthread_create 這種標準的執行緒介面。顯然 Golang 執行期不是什麼正常的應用程式。

thr_new 的兩個參數分別是一個 struct param 結構以及其大小。在 src/runtime/os_freebsd.go 裡面使用起來像是這個樣子:

...
        param := thrparam{
                start_func: abi.FuncPCABI0(thr_start),
                arg:        unsafe.Pointer(mp),
                stack_base: mp.g0.stack.lo,
                stack_size: uintptr(stk) - mp.g0.stack.lo,
                child_tid:  nil, // minit will record tid
                parent_tid: nil,
                tls_base:   unsafe.Pointer(&mp.tls[0]),
                tls_size:   unsafe.Sizeof(mp.tls),
        }

        var oset sigset
        sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
        ret := thr_new(&param, int32(unsafe.Sizeof(param)))
        sigprocmask(_SIG_SETMASK, &oset, nil)
...

與其它的作業系統大同小異,關鍵的系統呼叫前後都有 sigprocmask 在保護這個過程不受非同步的訊號影響。又,這個 param 結構體裡面,預先填入了三項我們最關心的內容:執行緒物件的指標(mp)置放於 arg 成員變數內;堆疊的起始位址與大小分別放在 stack* 成員變數中,其內容提取自 mp.g0.stack;新執行緒的入口函數給定為 thr_start

參考 src/runtime/sys_freebsd_arm64.s 裡面的 thr_new 包裝函式(wrapper function),這是呼叫流程的下一站:

// func thr_new(param *thrparam, size int32) int32
TEXT runtime·thr_new(SB),NOSPLIT,$0
        MOVD    param+0(FP), R0
        MOVW    size+8(FP), R1
        MOVD    $SYS_thr_new, R8
        SVC
        BCC     ok
        NEG     R0, R0
ok:
        MOVW    R0, ret+16(FP)
        RET

結果這是毫不囉唆的呼叫了系統呼叫(ARM64 裡面使用的是 SVC 指令),再下一站,則是 FreeBSD 系統保證的,新的執行緒可以直接從入口函數開始執行。(這與 Linux 更類似 fork 的行為不同,在上一節裡面我們展示了,入口函數的跳轉是由 Golang 執行期自己進行的。)

所以下一站是,thr_start,位在同一個檔案之中,

        // set up g
        MOVD    m_g0(R0), g
        MOVD    R0, g_m(g)
        BL      emptyfunc<>(SB)  // fault if stack check is wrong
        BL      runtime·mstart(SB)

        MOVD    $2, R8  // crash (not reached)
        MOVD    R8, (R8)
        RET

果然,還是得讓共常式暫存器 g 設置起來才行,之後還是跳轉到 mstart 開始。

其他相關方法

mexit

如果是 m0 進到這個函數的話,Golang 實作上讓它卡住,以避免它成為作業系統難以處理的殭屍。之後,也有各作業系統自行定義的 muninit 函數,可以釋放一些在 minit 配置的資源。

但所謂的釋放,不是呼叫某個複雜的 free 函數去通知記憶體管理系統,而是將相關的指標清空,讓之後觸發的垃圾回收機制去清理。

        // Remove m from allm.
        lock(&sched.lock)
        for pprev := &allm; *pprev != nil; pprev = &(*pprev).alllink {
                if *pprev == m {
                        *pprev = m.alllink
                        goto found
                }
        }

之後,透過這一個遍歷 allm 的迴圈,取得當前的執行緒,之後會再呼叫到作業系統自行定義的 mdestroy,做更徹底的資源釋放。

Linux 的話,muninit 釋放了一些訊號相關的東西,而 mdestroy 則是留空的。

小結

予焦啦!今天瀏覽了 Golang 的 m 結構體,以及它為什麼能夠大致上與作業系統所提供的執行緒牽扯上關係。我們走訪了與 m 相關的函數,從作業系統插手之前的 newm 系列,到作業系統生成之後、讓新生執行緒進入到 Golang 的境界的 mstart 系列。最後我們也帶出幾個釋放 m 結構的函數。

針對兩者中間的部分,由於 Hoddarla/ethanol 也是以成為作業系統核心為目標,所以當然不能忽略。我們挑選了兩種系統組合,瀏覽過它們使用的執行緒系統呼叫之後,我們可以深切地感受到,作業系統是如何提供一個功能強大的抽象層,來讓使用者空間透過簡單的準備(堆疊設置、記憶體讀寫)與模式的轉換(RISC-V 的 ECALL 或 ARM 的 SVC),就可以安心讓作業系統幫忙安排資源的調配。

無論如何,各位讀者,我們明天再會!


上一篇
予焦啦!實作上下文機制
下一篇
予焦啦!實作基本排程
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言